<?php

declare(strict_types=1);

/*

    Copyright (c) 2009-2019 F3::Factory/Bong Cosca, All rights reserved.

    This file is part of the Fat-Free Framework (http://fatfreeframework.com).

    This is free software: you can redistribute it and/or modify it under the
    terms of the GNU General Public License as published by the Free Software
    Foundation, either version 3 of the License, or later.

    Fat-Free Framework is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    General Public License for more details.

    You should have received a copy of the GNU General Public License along
    with Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.

*/

namespace Shoofly\Database\Jig;

use Shoofly\Database\Cursor;
use Shoofly\Database\Jig;
use Shoofly\Base;
use Shoofly\Cache;

//! Flat-file DB mapper
class Mapper extends Cursor
{
    //! Flat-file DB wrapper
    protected $db;
    //! Data file
    protected $file;
    //! Document identifier
    protected $id;
    //! Document contents
    protected $document = [];
    //! field map-reduce handlers
    protected $reduce;

    /**
    *   Instantiate class
    *   @return void
    *   @param $db object
    *   @param $file string
    **/
    public function __construct(Jig $db, $file)
    {
        $this->db = $db;
        $this->file = $file;
        $this->reset();
    }

    /**
    *   Return database type
    *   @return string
    **/
    public function dbtype()
    {
        return 'Jig';
    }

    /**
    *   Return TRUE if field is defined
    *   @return bool
    *   @param $key string
    **/
    public function exists($key)
    {
        return array_key_exists($key, $this->document);
    }

    /**
    *   Assign value to field
    *   @return scalar|FALSE
    *   @param $key string
    *   @param $val scalar
    **/
    public function set($key, $val)
    {
        return ($key == '_id') ? false : ($this->document[$key] = $val);
    }

    /**
    *   Retrieve value of field
    *   @return scalar|FALSE
    *   @param $key string
    **/
    public function &get($key)
    {
        if ($key == '_id' || $key == 'db_id') {
            return $this->db_id;
        }
        if (array_key_exists($key, $this->document)) {
            return $this->document[$key];
        }
        user_error(sprintf(self::E_Field, $key), E_USER_ERROR);
    }

    /**
    *   Delete field
    *   @return NULL
    *   @param $key string
    **/
    public function clear($key)
    {
        if ($key != '_id') {
            unset($this->document[$key]);
        }
    }

    /**
    *   Convert array to mapper object
    *   @return object
    *   @param $id string
    *   @param $row array
    **/
    public function factory($id, $row)
    {
        $mapper = clone($this);
        $mapper->reset();
        $mapper->id = $id;
        foreach ($row as $field => $val) {
            $mapper->document[$field] = $val;
        }
        $mapper->query = [clone($mapper)];
        if (isset($mapper->trigger['load'])) {
            Base::instance()->call($mapper->trigger['load'], $mapper);
        }
        return $mapper;
    }

    /**
    *   Return fields of mapper object as an associative array
    *   @return array
    *   @param $obj object
    **/
    public function cast($obj = null)
    {
        if (!$obj) {
            $obj = $this;
        }
        return $obj->document + ['_id' => $this->db_id];
    }

    /**
    *   Convert tokens in string expression to variable names
    *   @return string
    *   @param $str string
    **/
    public function token($str)
    {
        $str = preg_replace_callback(
            '/(?<!\w)@(\w(?:[\w\.\[\]])*)/',
            function ($token) {
                // Convert from JS dot notation to PHP array notation
                return '$' . preg_replace_callback(
                    '/(\.\w+)|\[((?:[^\[\]]*|(?R))*)\]/',
                    function ($expr) {
                        $fw = Base::instance();
                        return
                            '[' .
                            ($expr[1] ?
                                $fw->stringify(substr($expr[1], 1)) :
                                (preg_match(
                                    '/^\w+/',
                                    $mix = $this->token($expr[2])
                                ) ?
                                    $fw->stringify($mix) :
                                    $mix)) .
                            ']';
                    },
                    $token[1]
                );
            },
            $str
        );
        return trim($str);
    }

    /**
    *   Return records that match criteria
    *   @return static[]|FALSE
    *   @param $filter array
    *   @param $options array
    *   @param $ttl int|array
    *   @param $log bool
    **/
    public function find($filter = null, array $options = null, $ttl = 0, $log = true)
    {
        if (!$options) {
            $options = [];
        }
        $options += [
            'order' => null,
            'limit' => 0,
            'offset' => 0,
            'group' => null,
        ];
        $fw = Base::instance();
        $cache = Cache::instance();
        $db = $this->db;
        $now = microtime(true);
        $data = [];
        $tag = '';
        if (is_array($ttl)) {
            list($ttl,$tag) = $ttl;
        }
        if (!$fw->CACHE || !$ttl || !($cached = $cache->exists(
            $hash = $fw->hash($this->db->dir() .
                $fw->stringify([$filter,$options])) . ($tag ? '.' . $tag : '') . '.jig',
            $data
        )) ||
            $cached[0] + $ttl < microtime(true)
        ) {
            $data = $db->read($this->file);
            if (is_null($data)) {
                return false;
            }
            foreach ($data as $id => &$doc) {
                $doc['_id'] = $id;
                unset($doc);
            }
            if ($filter) {
                if (!is_array($filter)) {
                    return false;
                }
                // Normalize equality operator
                $expr = preg_replace('/(?<=[^<>!=])=(?!=)/', '==', $filter[0]);
                // Prepare query arguments
                $args = isset($filter[1]) && is_array($filter[1]) ?
                    $filter[1] :
                    array_slice($filter, 1, null, true);
                $args = is_array($args) ? $args : [1 => $args];
                $keys = $vals = [];
                $tokens = array_slice(
                    token_get_all('<?php ' . $this->token($expr)),
                    1
                );
                $data = array_filter(
                    $data,
                    function ($_row) use ($fw, $args, $tokens) {
                        $_expr = '';
                        $ctr = 0;
                        $named = false;
                        foreach ($tokens as $token) {
                            if (is_string($token)) {
                                if ($token == '?') {
                                    // Positional
                                    ++$ctr;
                                    $key = $ctr;
                                } else {
                                    if ($token == ':') {
                                        $named = true;
                                    } else {
                                        $_expr .= $token;
                                    }
                                    continue;
                                }
                            } elseif ($named &&
                                token_name($token[0]) == 'T_STRING'
                            ) {
                                $key = ':' . $token[1];
                                $named = false;
                            } else {
                                $_expr .= $token[1];
                                continue;
                            }
                                $_expr .= $fw->stringify(
                                    is_string($args[$key]) ?
                                    addcslashes($args[$key], '\'') :
                                    $args[$key]
                                );
                        }
                        // Avoid conflict with user code
                        unset($fw, $tokens, $args, $ctr, $token, $key, $named);
                        extract($_row);
                        // Evaluate pseudo-SQL expression
                        return eval('return ' . $_expr . ';');
                    }
                );
            }
            if (isset($options['group'])) {
                $cols = array_reverse($fw->split($options['group']));
                // sort into groups
                $data = $this->sort($data, $options['group']);
                foreach ($data as $i => &$row) {
                    if (!isset($prev)) {
                        $prev = $row;
                        $prev_i = $i;
                    }
                    $drop = false;
                    foreach ($cols as $col) {
                        if ($prev_i != $i && array_key_exists($col, $row) &&
                            array_key_exists($col, $prev) && $row[$col] == $prev[$col]
                        ) {
                            // reduce/modify
                            $drop = !isset($this->reduce[$col]) || call_user_func_array(
                                $this->reduce[$col][0],
                                [&$prev,&$row]
                            ) !== false;
                        } elseif (isset($this->reduce[$col])) {
                            $null = null;
                            // initial
                            call_user_func_array($this->reduce[$col][0], [&$row,&$null]);
                        }
                    }
                    if ($drop) {
                        unset($data[$i]);
                    } else {
                        $prev=&$row;
                        $prev_i = $i;
                    }
                    unset($row);
                }
                // finalize
                if ($this->reduce[$col][1]) {
                    foreach ($data as $i => &$row) {
                        $row = call_user_func($this->reduce[$col][1], $row);
                        if (!$row) {
                            unset($data[$i]);
                        }
                        unset($row);
                    }
                }
            }
            if (isset($options['order'])) {
                $data = $this->sort($data, $options['order']);
            }
            $data = array_slice(
                $data,
                $options['offset'],
                $options['limit'] ?: null,
                true
            );
            if ($fw->CACHE && $ttl) {
                // Save to cache backend
                $cache->set($hash, $data, $ttl);
            }
        }
        $out = [];
        foreach ($data as $id => &$doc) {
            unset($doc['_id']);
            $out[] = $this->factory($id, $doc);
            unset($doc);
        }
        if ($log && isset($args)) {
            if ($filter) {
                foreach ($args as $key => $val) {
                    $vals[] = $fw->stringify(is_array($val) ? $val[0] : $val);
                    $keys[] = '/' . (is_numeric($key) ? '\?' : preg_quote($key)) . '/';
                }
            }
            $db->jot('(' . sprintf('%.1f', 1e3 * (microtime(true) - $now)) . 'ms) ' .
                $this->file . ' [find] ' .
                ($filter ? preg_replace($keys, $vals, $filter[0], 1) : ''));
        }
        return $out;
    }

    /**
    *   Sort a collection
    *   @param $data
    *   @param $cond
    *   @return mixed
    */
    protected function sort($data, $cond)
    {
        $cols = Base::instance()->split($cond);
        uasort(
            $data,
            function ($val1, $val2) use ($cols) {
                foreach ($cols as $col) {
                    $parts = explode(' ', $col, 2);
                    $order = empty($parts[1]) ?
                        SORT_ASC :
                        constant($parts[1]);
                    $col = $parts[0];
                    if (!array_key_exists($col, $val1)) {
                        $val1[$col] = null;
                    }
                    if (!array_key_exists($col, $val2)) {
                        $val2[$col] = null;
                    }
                    list($v1,$v2) = [$val1[$col],$val2[$col]];
                    if ($out = strnatcmp($v1 ?: '', $v2 ?: '') *
                        (($order == SORT_ASC) * 2 - 1)
                    ) {
                        return $out;
                    }
                }
                return 0;
            }
        );
        return $data;
    }

    /**
    *   Add reduce handler for grouped fields
    *   @param $key string
    *   @param $handler callback
    *   @param $finalize callback
    */
    public function reduce($key, $handler, $finalize = null)
    {
        $this->reduce[$key] = [$handler,$finalize];
    }

    /**
    *   Count records that match criteria
    *   @return int
    *   @param $filter array
    *   @param $options array
    *   @param $ttl int|array
    **/
    public function count($filter = null, array $options = null, $ttl = 0)
    {
        $now = microtime(true);
        $out = count($this->find($filter, $options, $ttl, false));
        $this->db->jot('(' . sprintf('%.1f', 1e3 * (microtime(true) - $now)) . 'ms) ' .
            $this->file . ' [count] ' . ($filter ? json_encode($filter) : ''));
        return $out;
    }

    /**
    *   Return record at specified offset using criteria of previous
    *   load() call and make it active
    *   @return array
    *   @param $ofs int
    **/
    public function skip($ofs = 1)
    {
        $this->document = ($out = parent::skip($ofs)) ? $out->document : [];
        $this->db_id = $out ? $out->id : null;
        if ($this->document && isset($this->trigger['load'])) {
            Base::instance()->call($this->trigger['load'], $this);
        }
        return $out;
    }

    /**
    *   Insert new record
    *   @return array
    **/
    public function insert()
    {
        if ($this->db_id) {
            return $this->update();
        }
        $db = $this->db;
        $now = microtime(true);
        while (($id = uniqid('', true)) &&
            ($data=&$db->read($this->file)) && isset($data[$id]) &&
            !connection_aborted()
        ) {
            usleep(mt_rand(0, 100));
        }
        $this->db_id = $id;
        $pkey = ['_id' => $this->db_id];
        if (isset($this->trigger['beforeinsert']) &&
            Base::instance()->call(
                $this->trigger['beforeinsert'],
                [$this,$pkey]
            ) === false
        ) {
            return $this->document;
        }
        $data[$id] = $this->document;
        $db->write($this->file, $data);
        $db->jot('(' . sprintf('%.1f', 1e3 * (microtime(true) - $now)) . 'ms) ' .
            $this->file . ' [insert] ' . json_encode($this->document));
        if (isset($this->trigger['afterinsert'])) {
            Base::instance()->call(
                $this->trigger['afterinsert'],
                [$this,$pkey]
            );
        }
        $this->load(['@_id=?',$this->db_id]);
        return $this->document;
    }

    /**
    *   Update current record
    *   @return array
    **/
    public function update()
    {
        $db = $this->db;
        $now = microtime(true);
        $data=&$db->read($this->file);
        if (isset($this->trigger['beforeupdate']) &&
            Base::instance()->call(
                $this->trigger['beforeupdate'],
                [$this,['_id' => $this->db_id]]
            ) === false
        ) {
            return $this->document;
        }
        $data[$this->db_id] = $this->document;
        $db->write($this->file, $data);
        $db->jot('(' . sprintf('%.1f', 1e3 * (microtime(true) - $now)) . 'ms) ' .
            $this->file . ' [update] ' . json_encode($this->document));
        if (isset($this->trigger['afterupdate'])) {
            Base::instance()->call(
                $this->trigger['afterupdate'],
                [$this,['_id' => $this->db_id]]
            );
        }
        return $this->document;
    }

    /**
    *   Delete current record
    *   @return bool
    *   @param $filter array
    *   @param $quick bool
    **/
    public function erase($filter = null, $quick = false)
    {
        $db = $this->db;
        $now = microtime(true);
        $data=&$db->read($this->file);
        $pkey = ['_id' => $this->db_id];
        if ($filter) {
            foreach ($this->find($filter, null, false) as $mapper) {
                if (!$mapper->erase(null, $quick)) {
                    return false;
                }
            }
            return true;
        } elseif (isset($this->db_id)) {
            unset($data[$this->db_id]);
            parent::erase();
        } else {
            return false;
        }
        if (!$quick && isset($this->trigger['beforeerase']) &&
            Base::instance()->call(
                $this->trigger['beforeerase'],
                [$this,$pkey]
            ) === false
        ) {
            return false;
        }
        $db->write($this->file, $data);
        if ($filter) {
            $args = isset($filter[1]) && is_array($filter[1]) ?
                $filter[1] :
                array_slice($filter, 1, null, true);
            $args = is_array($args) ? $args : [1 => $args];
            foreach ($args as $key => $val) {
                $vals[] = Base::instance()->
                    stringify(is_array($val) ? $val[0] : $val);
                $keys[] = '/' . (is_numeric($key) ? '\?' : preg_quote($key)) . '/';
            }
        }
        $db->jot('(' . sprintf('%.1f', 1e3 * (microtime(true) - $now)) . 'ms) ' .
            $this->file . ' [erase] ' .
            ($filter ? preg_replace($keys, $vals, $filter[0], 1) : ''));
        if (!$quick && isset($this->trigger['aftererase'])) {
            Base::instance()->call(
                $this->trigger['aftererase'],
                [$this,$pkey]
            );
        }
        return true;
    }

    /**
    *   Reset cursor
    *   @return NULL
    **/
    public function reset()
    {
        $this->db_id = null;
        $this->document = [];
        parent::reset();
    }

    /**
    *   Hydrate mapper object using hive array variable
    *   @return NULL
    *   @param $var array|string
    *   @param $func callback
    **/
    public function copyfrom($var, $func = null)
    {
        if (is_string($var)) {
            $var = Base::instance()->$var;
        }
        if ($func) {
            $var = call_user_func($func, $var);
        }
        foreach ($var as $key => $val) {
            $this->set($key, $val);
        }
    }

    /**
    *   Populate hive array variable with mapper fields
    *   @return NULL
    *   @param $key string
    **/
    public function copyto($key)
    {
        $var=&Base::instance()->ref($key);
        foreach ($this->document as $key => $field) {
            $var[$key] = $field;
        }
    }

    /**
    *   Return field names
    *   @return array
    **/
    public function fields()
    {
        return array_keys($this->document);
    }

    /**
    *   Retrieve external iterator for fields
    *   @return object
    **/
    public function getiterator()
    {
        return new \ArrayIterator($this->cast());
    }
}
